Skip to content

Conversation

@felixweinberger
Copy link
Contributor

@felixweinberger felixweinberger commented Nov 17, 2025

Summary

Implements SEP-1699 which enables servers to disconnect SSE connections at will by sending priming events and retry fields.

Motivation and Context

SEP-1699 introduces SSE polling behavior that allows servers to control client reconnection timing and close connections gracefully. This enables more efficient resource management on the server side while maintaining resumability.

We implement this on the POST SSE stream as implied by the SEP language linked above. I.e. when a server establishes an SSE stream:

  1. It's first message will be an event including no data, only an event ID.
  2. After that, it may call cancelSSEStream to close the stream while still gathering the events.
  3. The client can start "polling" the SSE stream based on the retryInterval supplied by the server before disconnection.

How Has This Been Tested?

Breaking Changes

None. Client falls back to exponential backoff if no retry field is provided.

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update

Checklist

  • I have read the MCP Documentation
  • My code follows the repository's style guidelines
  • New and existing tests pass locally
  • I have added appropriate error handling
  • I have added or updated documentation as needed

@pkg-pr-new
Copy link

pkg-pr-new bot commented Nov 17, 2025

Open in StackBlitz

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/sdk@1129

commit: 0b1898b

toolResolve!();
});

it('should support POST SSE polling with client reconnection', async () => {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

probably the most interesting test

const primingEventId = primingIdMatch![1];

// Server closes the stream to trigger polling
transport.closeSSEStream(100);
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this feels slightly weird. The whole point is for the server to be able to close SSE streams, so maybe this method should be exposed on server?

On the other hand, this disconnection only makes sense in the SHTTP transport - whereas the server is actually transport agnostic.

Maybe the correct expectation is to have the server call this method directly via its reference to the transport if necessary and actually available, so this might be fine actually.`

Copy link
Contributor

@mattzcarey mattzcarey Nov 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this should be called by the user on the server. A bunch of frameworks dont give the user easy access to the transport but they do the server. Proxy by reference is good

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To me it would feel very weird to have a closeSSEStream on the server that should have no knowledge of sessions let alone streams.

A bunch of frameworks dont give the user easy access to the transport but they do the server. Proxy by reference is good

This is a good point but maybe the right API is to expose the transport from the server?

Add support for SSE retry field to enable server-controlled client reconnection timing.

Client changes:
- Capture server-provided retry field from SSE events
- Use retry value for reconnection delay instead of exponential backoff
- Reconnect on graceful stream close with Last-Event-ID header

Server changes:
- Add retryInterval option to StreamableHTTPServerTransportOptions
- Send priming events with id/retry/empty-data when eventStore is configured
- Add closeSSEStream(requestId) API to close POST SSE streams for polling
- Priming events establish resumption capability before actual messages

Tests:
- Client: retry field capture, exponential backoff fallback, graceful reconnection
- Server: priming events, retry field, stream closure, POST SSE polling flow
@felixweinberger felixweinberger marked this pull request as ready for review November 18, 2025 11:29
@felixweinberger felixweinberger requested a review from a team as a code owner November 18, 2025 11:29
@felixweinberger
Copy link
Contributor Author

felixweinberger commented Nov 18, 2025

Hi @jonathanhefner would love feedback on whether this accurately captures the intent of your original SEP

@paoloricciuti in case you want to TAL as you had valuable input on modelcontextprotocol/conformance#35

Copy link

@paoloricciuti paoloricciuti left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So we actually had a brief discussion about this on discord and I think the spec should be clearer here: from the discussion we had the reconnection should be different from the standalone stream. So if the client was doing two POST requests each POST would have it's own lastEventId.

On reconnect this should basically result in 3 new GET SSE streams, two to get the remaining notificiations/responses from the POST requests and one as a standalone stream for the new notifications (and to resume the notifications eventually sent during the disconnection period).

However right now the server still errors out if there's more that one SSE stream. Should this be fixed? Is this even the right interpretation of the spec?

const primingEventId = primingIdMatch![1];

// Server closes the stream to trigger polling
transport.closeSSEStream(100);

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To me it would feel very weird to have a closeSSEStream on the server that should have no knowledge of sessions let alone streams.

A bunch of frameworks dont give the user easy access to the transport but they do the server. Proxy by reference is good

This is a good point but maybe the right API is to expose the transport from the server?

Copy link
Member

@jonathanhefner jonathanhefner left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

would love feedback on whether this accurately captures the intent of your original SEP

Yes, looks great! Thank you! 😃

toolResolve!();

// Give the tool time to complete and store the result
await new Promise(resolve => setTimeout(resolve, 50));
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure if this is a concern, but this could lead to flaky tests. I'm not sure what we could await as an alternative though.

Addresses PR feedback from paoloricciuti requesting test coverage
for the scenario where multiple messages are sent while the SSE
client is disconnected. Uses a batch of tool calls to generate
multiple responses that get stored and replayed on reconnection.
@felixweinberger
Copy link
Contributor Author

felixweinberger commented Nov 19, 2025

So we actually had a brief discussion about this on discord and I think the spec should be clearer here: from the discussion we had the reconnection should be different from the standalone stream. So if the client was doing two POST requests each POST would have it's own lastEventId.

On reconnect this should basically result in 3 new GET SSE streams, two to get the remaining notificiations/responses from the POST requests and one as a standalone stream for the new notifications (and to resume the notifications eventually sent during the disconnection period).

However right now the server still errors out if there's more that one SSE stream. Should this be fixed? Is this even the right interpretation of the spec?

Ack, after the discusson on Discord seems clear we need to not error out on multiple streams. Still figuring out the right way to do that on this PR, will come back with an update soon, moving to draft for now.

@felixweinberger felixweinberger marked this pull request as draft November 19, 2025 00:22
- Fix replayEvents to use streamId from last-event-id header
- Add conflict check per streamId (not global)
- Add missing close handler to clean up stream mapping
- Add test demonstrating concurrent GET streams resuming different POST streams

This aligns with the spec: "The client MAY remain connected to
multiple SSE streams simultaneously."
@felixweinberger
Copy link
Contributor Author

Horrible timeout based tests - but looking for feedback on the implementation before figuring out prettier tests.

- Add closeStandaloneSSEStream() method to allow server to close the
  standalone GET notification stream, triggering client reconnection
- Send priming event with retry field on GET streams (when eventStore
  configured) for resumability
- Add tests for GET stream priming events and closeStandaloneSSEStream
- Fix flaky test timeout for POST SSE polling test
@felixweinberger
Copy link
Contributor Author

@felixweinberger will this require any changes to the Inspector or will it just be contained within SDK operations?

I think there should not be any changes required - this is primarily about server behavior, the SDK should handle resumption automatically.

If we wanted to have some debugging insight into resumption / reconnection though that seems like it might be valuable? E.g. surfacing reconnection events, showing the polling, showing retry intervals`, showing when a server actually did choose to disconnect.

@cliffhall
Copy link
Member

@felixweinberger will this require any changes to the Inspector or will it just be contained within SDK operations?

I think there should not be any changes required - this is primarily about server behavior, the SDK should handle resumption automatically.

If we wanted to have some debugging insight into resumption / reconnection though that seems like it might be valuable? E.g. surfacing reconnection events, showing the polling, showing retry intervals`, showing when a server actually did choose to disconnect.

Would we need a demo server to generate some of this behavior, or is there one in the SDK examples we could use to test with?

@felixweinberger
Copy link
Contributor Author

@felixweinberger will this require any changes to the Inspector or will it just be contained within SDK operations?

I think there should not be any changes required - this is primarily about server behavior, the SDK should handle resumption automatically.
If we wanted to have some debugging insight into resumption / reconnection though that seems like it might be valuable? E.g. surfacing reconnection events, showing the polling, showing retry intervals`, showing when a server actually did choose to disconnect.

Would we need a demo server to generate some of this behavior, or is there one in the SDK examples we could use to test with?

Good point, let me see if I can make an exampleServer...

felixweinberger and others added 6 commits November 23, 2025 20:30
Per SEP-1699, clients should auto-reconnect via GET when server
closes POST SSE streams mid-operation. This enables polling for
long-running tool calls.

Changes:
- Enable isReconnectable=true for POST SSE streams in client
- Add example client demonstrating SSE polling with server
- Update test to expect GET reconnection after POST stream fails
The isReconnectable=true change was too aggressive - per SEP-1699,
reconnection should only happen after server sends a priming event
with an event ID, not on all POST stream failures.

Keep the example client for now; proper reconnection logic TBD.
@felixweinberger
Copy link
Contributor Author

@cliffhall added src/examples/client/ssePollingClient.ts and src/examples/server/ssePollingExample.ts which demonstrate the feature together.

CleanShot 2025-11-23 at 21 00 09

Per SEP-1699, servers may close SSE streams after sending a priming event
with an event ID. This change enables automatic reconnection for POST-initiated
streams once they've received at least one event with an ID.

The change introduces `hasPrimingEvent` tracking in `_handleSseStream`:
- GET streams remain always reconnectable (isReconnectable=true)
- POST streams become reconnectable after receiving an event with ID
- Reconnection uses `canResume = isReconnectable || hasPrimingEvent`

This enables the SSE polling example client to work correctly with
server-initiated disconnection and automatic client reconnection.

// Resume the notification stream using lastEventId
// This is the key part - we're resuming the same long-running tool using lastEventId
await client2.request(
Copy link
Contributor Author

@felixweinberger felixweinberger Nov 23, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test was actually not spec compliant - we were doing a second post request to continue.

The spec actually requires all resumption requests to be GET requests.

@paoloricciuti
Copy link

Sorry I was at my company off-site and couldn't review it properly, will take a look tomorrow

* @param lastEventId The ID of the last received event for resumability
* @param attemptCount Current reconnection attempt count for this specific stream
*/
private _scheduleReconnection(options: StartSSEOptions, attemptCount = 0): void {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not for this PR but it would be good to be able to overwrite this method. For clients which aren't running all the time this will need some input externally from the client itself to wake it up.

Copy link
Contributor Author

@felixweinberger felixweinberger Nov 24, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good idea, made an issue for now #1162

this._streamMapping.set(streamId, res);

// Use streamId from getStreamIdForEventId if available, otherwise from replay
const finalStreamId = streamId ?? replayedStreamId;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could you explain this logic for me? I'm not sure I understand why the choice in ids. Wouldnt we only do this is the replayed one is now closed.

Copy link
Contributor Author

@felixweinberger felixweinberger Nov 24, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If getStreamIdForEventId is implemented: We use streamId from that lookup. This is the "proper" way - the event store explicitly tells us which stream the event belongs to, and we've already done conflict checking with this ID at line 403. We need this to be able to identify the correct stream encoded in the ID.

If getStreamIdForEventId isn't implemented (for backwards compat): streamId will be undefined, so we fall back to replayedStreamId returned from replayEventsAfter. This allows older event store implementations that don't implement the optional method to still work.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually, thinking through this again - I think I see what you're saying, we don't actually need streamId ?? replayedStreamId because there isn't really a case where streamId != replayedStreamId if getStreamIdForEventId is implemented correctly.

So we could probably simplify the below to just directly reference replayedStreamId.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pushed a commit to update this, lmk what you think.

Copy link
Contributor

@mattzcarey mattzcarey left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tentative approval

- Fix _scheduleReconnection comment: delay can now come from server retry
  field, not just exponential backoff
- Fix getStreamIdForEventId comment: SDK doesn't parse streamId::... format,
  it uses replayEventsAfter return value as fallback
Always use replayedStreamId from replayEventsAfter instead of preferring
streamId from getStreamIdForEventId. Both should return the same value
for a correctly implemented event store, and replayEventsAfter is the
authoritative source since it's the required method.
Copy link

@findleyr findleyr left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi! Just a few comments as I compared this to what I was planning for the Go SDK. Feel free to disregard, though I'm curious of your thoughts in any case.


// Server decides to disconnect the client to free resources
// Client will reconnect via GET with Last-Event-ID after retryInterval
const transport = transports.get(extra.sessionId!);

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For the Go SDK, I was considering adding a (possibly nil) callback on extra, to avoid this type of coupling between tool and transport. Would that be valuable here?

* Close an SSE stream for a specific request, triggering client reconnection.
* Use this to implement polling behavior during long-running operations -
* client will reconnect after the retry interval specified in the priming event.
*/

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: should this operation only be valid if there's an event store?
I don't actually know, but it seems a bit problematic that the tool has to be aware both that it's being served on a streamable transport, and that this transport has an event store.

If the intent is to implement this in the actual tool, then it might be better to pass a callback in the request extra.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should closeSSEStream take a retry duration? Again, if we're delegating control over this to the tool, one can imagine that the tool would want to be able to control its own replay pacing, and that the retry timeout for one tool might not be appropriate for another.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Implement SEP-1699: Support SSE Polling via Server-Side Disconnect

7 participants